Repository navigation
feat: redact secrets from tool results by default - #170
Conversation
API key secrets, webhook signing keys, password hashes and other secrets went into the model's context, transcripts and caches the moment a call returned them. Redact every field Appwrite's spec marks format: password, read from the Console SDK model, leaving a fingerprint and a note; reveal_secrets returns them in full when the user asks.
0.16.0 carries format: password on every secret response field, which the redaction reads. It also adds 36 catalog methods (1065 hosted, 720 for API keys), so the documented counts and the flow diagram move with it.
🟢 Tier S · Ready to mergeAdds default redaction and SHA-256 fingerprints for API-spec password-format fields in Appwrite tool results, with an opt-in Latest changes: The latest commits pass
📂 Walkthrough · 14
Reviewed the commits since |
A response the SDK fails to hydrate is returned raw, without the model's field types, so secrets in it went through unredacted. Redact it by name: every field name an SDK model marks format: password.
The findings that requested changes are resolved. See the summary comment for what is still open.
A create or rotate whose response the SDK fails to hydrate returns the raw payload, and redacting it regardless of reveal_secrets could leave a one-time secret unrecoverable.
What does this PR do?
Stops the MCP server from putting secrets into the model's context. Every response field Appwrite's API spec marks
format: passwordis redacted from tool results by default and replaced with a fingerprint. A newreveal_secretsoption returns the values when the user asks to see them.The problem
Whatever a tool returns goes into the model's context, then into the client's local transcripts, subagent transcripts and tool-result caches. Some Appwrite responses carry live secrets, and the MCP returned them in full:
organization_create_ephemeral_project_keysecret, on every callsecretorganization_create_project_keysecretwebhooks_create,webhooks_update_secretsecretusers_list,users_getpassword(the argon2 hash), for every userAs a result, a key created or rotated through the MCP was leaked the moment it existed, so an agent couldn't rotate a leaked key without leaking the replacement. The CLI already hides these by default, so the two surfaces behaved differently on the same API.
Where the list of secret fields comes from
There is no hand-kept list in this repo. Secret fields are marked once in Appwrite's API spec with OpenAPI's standard
format: password, and both this server and the CLI read that marking:Field(..., json_schema_extra={'format': 'password'}).appwrite-console0.16.0 (feat: Console Python SDK update for version 0.16.0 sdk-for-console-python#25) ships it, with 88 marked fields in the console SDK.How it works
New module,
redaction.py. After the SDK call,execute_registered_toolpasses the typed result toredact()before it's serialised.redact()walks the pydantic model tree and handles each marked field like this:"[redacted]", plus a sibling<field>Fingerprint:sha256:and the first 12 hex characters of its SHA-256. The agent can tell which key it created, or check a stored copy matches, without seeing it"[redacted]"Fields that aren't marked are serialised exactly as before (the same
model_dump(by_alias=True, mode="json", exclude_unset=True)the SDK'sto_dictuses).A note to the model. When anything was redacted, a second text item names the fields. It also tells the model to read the value again with
reveal_secrets=trueonly if the user explicitly asks, and never to repeat a create, which would make a new secret.reveal_secrets. It's a boolean onappwrite_call_toolandappwrite_call_write_tool, off by default, and it's stripped from the arguments sent to Appwrite. A write that reveals still needsconfirm_writeand areason, and the reason is recorded on the project's audit log as before.Where it applies.
appwrite_show).views._jsonnow parses only the first text item, so the note never breaks a view.What this affects
[redacted]and a fingerprint. Everything else is unchangedusers_listandusers_getpasswordcomes back as[redacted]with a fingerprint;$argon2…never appearssecretis redacted unlessreveal_secretsis setappwrite_call_toolandappwrite_call_write_toolgain the optionalreveal_secretspropertyselectqueriesappwrite-consolerequestsandurllib3Upgrading from 0.12 needed one test change: the SDK now fills defaults on partial documents. The
selecttest's second half asserted that full models reject partial rows, which no longer holds. The part that matters is still asserted: aselectresult comes back exactly as Appwrite sent it, with no defaults filled in.Test Plan
test_tools.py,test_reads_and_writes_run_only_through_their_own_tool:webhooks_create's result doesn't contain the stored signing key anywhere.secretis[redacted], andsecretFingerprintequals the stored key's SHA-256 prefix.secret.webhooks_update_secretwithreveal_secrets: truereturns the rotated key in full, with no note.test_server.py,test_format_appwrite_error_returns_the_unhydrated_payload: a keysecretin an unparsed payload comes back as[redacted]with the note.test_stdio.py,test_acts_in_the_configured_environment:users_listover stdio contains no$argon2. Every user'spasswordis[redacted]with asha256:fingerprint.ruff check,black --checkandpyright(0 errors) pass.dockerjob checks it.docs/appwrite-mcp-flow.pngfrom the SVG and compared it with the previous PNG: identical apart from the catalog line (38 services · 981 tools → 41 · 1065).